/*
 * Copyright (C) 2013 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.aplink.generic.google.datetimepicker.date;

import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Locale;

import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.AbsListView;
import android.widget.AbsListView.OnScrollListener;
import android.widget.ListView;

import com.aplink.generic.google.datetimepicker.Utils;
import com.aplink.generic.google.datetimepicker.date.DatePickerDialog.OnDateChangedListener;
import com.aplink.generic.google.datetimepicker.date.MonthAdapter.CalendarDay;

/**
 * This displays a list of months in a calendar format with selectable days.
 */
public abstract class DayPickerView extends ListView implements
        OnScrollListener, OnDateChangedListener {

	protected class ScrollStateRunnable implements Runnable {
		private int mNewState;

		/**
		 * Sets up the runnable with a short delay in case the scroll state
		 * immediately changes again.
		 *
		 * @param view
		 *            The list view that changed state
		 * @param scrollState
		 *            The new state it changed to
		 */
		public void doScrollStateChange(final AbsListView view,
				final int scrollState) {
			DayPickerView.this.mHandler.removeCallbacks(this);
			this.mNewState = scrollState;
			DayPickerView.this.mHandler.postDelayed(this,
					DayPickerView.SCROLL_CHANGE_DELAY);
		}

		@Override
		public void run() {
			DayPickerView.this.mCurrentScrollState = this.mNewState;
			if (Log.isLoggable(DayPickerView.TAG, Log.DEBUG)) {
				Log.d(DayPickerView.TAG, "new scroll state: " + this.mNewState
						+ " old state: "
						+ DayPickerView.this.mPreviousScrollState);
			}
			// Fix the position after a scroll or a fling ends
			if ((this.mNewState == OnScrollListener.SCROLL_STATE_IDLE)
					&& (DayPickerView.this.mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE)
					&& (DayPickerView.this.mPreviousScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL)) {
				DayPickerView.this.mPreviousScrollState = this.mNewState;
				int i = 0;
				View child = getChildAt(i);
				while ((child != null) && (child.getBottom() <= 0)) {
					child = getChildAt(++i);
				}
				if (child == null) {
					// The view is no longer visible, just return
					return;
				}
				final int firstPosition = getFirstVisiblePosition();
				final int lastPosition = getLastVisiblePosition();
				final boolean scroll = (firstPosition != 0)
						&& (lastPosition != (getCount() - 1));
				final int top = child.getTop();
				final int bottom = child.getBottom();
				final int midpoint = getHeight() / 2;
				if (scroll && (top < DayPickerView.LIST_TOP_OFFSET)) {
					if (bottom > midpoint) {
						smoothScrollBy(top, DayPickerView.GOTO_SCROLL_DURATION);
					} else {
						smoothScrollBy(bottom,
								DayPickerView.GOTO_SCROLL_DURATION);
					}
				}
			} else {
				DayPickerView.this.mPreviousScrollState = this.mNewState;
			}
		}
	}

	// The number of days to display in each week
	public static final int DAYS_PER_WEEK = 7;
	// How long the GoTo fling animation should last
	protected static final int GOTO_SCROLL_DURATION = 250;
	                                        public static int LIST_TOP_OFFSET = -1; // so
	// that
	                                        // the
	                                        // top
	                                        // line
	                                        // will
	                                        // be
	// How long to wait after receiving an onScrollStateChanged notification
	// before acting on it
	protected static final int SCROLL_CHANGE_DELAY = 40;
	// Affects when the month selection will change while scrolling up
	protected static final int SCROLL_HYST_WEEKS = 2;
	private static final String TAG = "MonthFragment";
	private static SimpleDateFormat YEAR_FORMAT = new SimpleDateFormat("yyyy",
	        Locale.getDefault());

	private static String getMonthAndYearString(final CalendarDay day) {
		final Calendar cal = Calendar.getInstance();
		cal.set(day.year, day.month, day.day);

		final StringBuffer sbuf = new StringBuffer();
		sbuf.append(cal.getDisplayName(Calendar.MONTH, Calendar.LONG,
				Locale.getDefault()));
		sbuf.append(" ");
		sbuf.append(DayPickerView.YEAR_FORMAT.format(cal.getTime()));
		return sbuf.toString();
	}

	protected MonthAdapter mAdapter;

	protected Context mContext;

	private DatePickerController mController;
	// which month should be displayed/highlighted [0-11]
	protected int mCurrentMonthDisplayed;

	// used for tracking what state listview is in
	protected int mCurrentScrollState = OnScrollListener.SCROLL_STATE_IDLE;
	protected int mDaysPerWeek = 7;

	// When the week starts; numbered like Time.<WEEKDAY> (e.g. SUNDAY=0).
	protected int mFirstDayOfWeek;

	// These affect the scroll speed and feel
	protected float mFriction = 1.0f;
	protected Handler mHandler;
	// under the separator
	// You can override these numbers to get a different appearance
	protected int mNumWeeks = 6;
	private boolean mPerformingScroll;
	// used for tracking during a scroll
	protected long mPreviousScrollPosition;
	// used for tracking what state listview is in
	protected int mPreviousScrollState = OnScrollListener.SCROLL_STATE_IDLE;

	// The last name announced by accessibility
	protected CharSequence mPrevMonthName;
	protected ScrollStateRunnable mScrollStateChangedRunnable = new ScrollStateRunnable();

	// highlighted time
	protected CalendarDay mSelectedDay = new CalendarDay();

	protected boolean mShowWeekNumber = false;

	protected CalendarDay mTempDay = new CalendarDay();

	public DayPickerView(final Context context, final AttributeSet attrs) {
		super(context, attrs);
		init(context);
	}

	public DayPickerView(final Context context,
			final DatePickerController controller) {
		super(context);
		init(context);
		setController(controller);
	}

	public abstract MonthAdapter createMonthAdapter(Context context,
			DatePickerController controller);

	/**
	 * Attempts to return the date that has accessibility focus.
	 *
	 * @return The date that has accessibility focus, or {@code null} if no date
	 *         has focus.
	 */
	private CalendarDay findAccessibilityFocus() {
		final int childCount = getChildCount();
		for (int i = 0; i < childCount; i++) {
			final View child = getChildAt(i);
			if (child instanceof MonthView) {
				final CalendarDay focus = ((MonthView) child)
						.getAccessibilityFocus();
				if (focus != null) {
					if (Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN_MR1) {
						// Clear focus to avoid ListView bug in Jelly Bean MR1.
						((MonthView) child).clearAccessibilityFocus();
					}
					return focus;
				}
			}
		}

		return null;
	}

	/**
	 * Gets the position of the view that is most prominently displayed within
	 * the list view.
	 */
	public int getMostVisiblePosition() {
		final int firstPosition = getFirstVisiblePosition();
		final int height = getHeight();

		int maxDisplayedHeight = 0;
		int mostVisibleIndex = 0;
		int i = 0;
		int bottom = 0;
		while (bottom < height) {
			final View child = getChildAt(i);
			if (child == null) {
				break;
			}
			bottom = child.getBottom();
			final int displayedHeight = Math.min(bottom, height)
					- Math.max(0, child.getTop());
			if (displayedHeight > maxDisplayedHeight) {
				mostVisibleIndex = i;
				maxDisplayedHeight = displayedHeight;
			}
			i++;
		}
		return firstPosition + mostVisibleIndex;
	}

	/**
	 * This moves to the specified time in the view. If the time is not already
	 * in range it will move the list so that the first of the month containing
	 * the time is at the top of the view. If the new time is already in view
	 * the list will not be scrolled unless forceScroll is true. This time may
	 * optionally be highlighted as selected as well.
	 *
	 * @param time
	 *            The time to move to
	 * @param animate
	 *            Whether to scroll to the given time or just redraw at the new
	 *            location
	 * @param setSelected
	 *            Whether to set the given time as selected
	 * @param forceScroll
	 *            Whether to recenter even if the time is already visible
	 * @return Whether or not the view animated to the new location
	 */
	public boolean goTo(final CalendarDay day, final boolean animate,
			final boolean setSelected, final boolean forceScroll) {

		// Set the selected day
		if (setSelected) {
			this.mSelectedDay.set(day);
		}

		this.mTempDay.set(day);
		final int position = ((day.year - this.mController.getMinYear()) * MonthAdapter.MONTHS_IN_YEAR)
				+ day.month;

		View child;
		int i = 0;
		int top = 0;
		// Find a child that's completely in the view
		do {
			child = getChildAt(i++);
			if (child == null) {
				break;
			}
			top = child.getTop();
			if (Log.isLoggable(DayPickerView.TAG, Log.DEBUG)) {
				Log.d(DayPickerView.TAG, "child at " + (i - 1) + " has top "
						+ top);
			}
		} while (top < 0);

		// Compute the first and last position visible
		int selectedPosition;
		if (child != null) {
			selectedPosition = getPositionForView(child);
		} else {
			selectedPosition = 0;
		}

		if (setSelected) {
			this.mAdapter.setSelectedDay(this.mSelectedDay);
		}

		if (Log.isLoggable(DayPickerView.TAG, Log.DEBUG)) {
			Log.d(DayPickerView.TAG, "GoTo position " + position);
		}
		// Check if the selected day is now outside of our visible range
		// and if so scroll to the month that contains it
		if ((position != selectedPosition) || forceScroll) {
			setMonthDisplayed(this.mTempDay);
			this.mPreviousScrollState = OnScrollListener.SCROLL_STATE_FLING;
			if (animate) {
				this.smoothScrollToPositionFromTop(position,
						DayPickerView.LIST_TOP_OFFSET,
						DayPickerView.GOTO_SCROLL_DURATION);
				return true;
			} else {
				postSetSelection(position);
			}
		} else if (setSelected) {
			setMonthDisplayed(this.mSelectedDay);
		}
		return false;
	}

	public void init(final Context context) {
		this.mHandler = new Handler();
		setLayoutParams(new LayoutParams(
				android.view.ViewGroup.LayoutParams.MATCH_PARENT,
				android.view.ViewGroup.LayoutParams.MATCH_PARENT));
		setDrawSelectorOnTop(false);

		this.mContext = context;
		setUpListView();
	}

	@Override
	protected void layoutChildren() {
		final CalendarDay focusedDay = findAccessibilityFocus();
		super.layoutChildren();
		if (this.mPerformingScroll) {
			this.mPerformingScroll = false;
		} else {
			restoreAccessibilityFocus(focusedDay);
		}
	}

	public void onChange() {
		refreshAdapter();
	}

	@Override
	public void onDateChanged() {
		goTo(this.mController.getSelectedDay(), false, true, true);
	}

	@Override
	public void onInitializeAccessibilityEvent(final AccessibilityEvent event) {
		super.onInitializeAccessibilityEvent(event);
		event.setItemCount(-1);
	}

	/**
	 * Necessary for accessibility, to ensure we support "scrolling" forward and
	 * backward in the month list.
	 */
	@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
	@Override
	public void onInitializeAccessibilityNodeInfo(
			final AccessibilityNodeInfo info) {
		super.onInitializeAccessibilityNodeInfo(info);
		info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
		info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
	}

	/**
	 * Updates the title and selected month if the view has moved to a new
	 * month.
	 */
	@Override
	public void onScroll(final AbsListView view, final int firstVisibleItem,
			final int visibleItemCount, final int totalItemCount) {
		final MonthView child = (MonthView) view.getChildAt(0);
		if (child == null) {
			return;
		}

		// Figure out where we are
		final long currScroll = (view.getFirstVisiblePosition() * child
				.getHeight()) - child.getBottom();
		this.mPreviousScrollPosition = currScroll;
		this.mPreviousScrollState = this.mCurrentScrollState;
	}

	@Override
	public void onScrollStateChanged(final AbsListView view,
			final int scrollState) {
		// use a post to prevent re-entering onScrollStateChanged before it
		// exits
		this.mScrollStateChangedRunnable.doScrollStateChange(view, scrollState);
	}

	/**
	 * When scroll forward/backward events are received, announce the newly
	 * scrolled-to month.
	 */
	@SuppressLint("NewApi")
	@Override
	public boolean performAccessibilityAction(final int action,
			final Bundle arguments) {
		if ((action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
				&& (action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD)) {
			return super.performAccessibilityAction(action, arguments);
		}

		// Figure out what month is showing.
		final int firstVisiblePosition = getFirstVisiblePosition();
		final int month = firstVisiblePosition % 12;
		final int year = (firstVisiblePosition / 12)
				+ this.mController.getMinYear();
		final CalendarDay day = new CalendarDay(year, month, 1);

		// Scroll either forward or backward one month.
		if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
			day.month++;
			if (day.month == 12) {
				day.month = 0;
				day.year++;
			}
		} else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
			final View firstVisibleView = getChildAt(0);
			// If the view is fully visible, jump one month back. Otherwise,
			// we'll just jump
			// to the first day of first visible month.
			if ((firstVisibleView != null) && (firstVisibleView.getTop() >= -1)) {
				// There's an off-by-one somewhere, so the top of the first
				// visible item will
				// actually be -1 when it's at the exact top.
				day.month--;
				if (day.month == -1) {
					day.month = 11;
					day.year--;
				}
			}
		}

		// Go to that month.
		Utils.tryAccessibilityAnnounce(this,
				DayPickerView.getMonthAndYearString(day));
		goTo(day, true, false, true);
		this.mPerformingScroll = true;
		return true;
	}

	public void postSetSelection(final int position) {
		clearFocus();
		post(new Runnable() {

			@Override
			public void run() {
				DayPickerView.this.setSelection(position);
			}
		});
		onScrollStateChanged(this, OnScrollListener.SCROLL_STATE_IDLE);
	}

	/**
	 * Creates a new adapter if necessary and sets up its parameters. Override
	 * this method to provide a custom adapter.
	 */
	protected void refreshAdapter() {
		if (this.mAdapter == null) {
			this.mAdapter = createMonthAdapter(getContext(), this.mController);
		} else {
			this.mAdapter.setSelectedDay(this.mSelectedDay);
		}
		// refresh the view with the new parameters
		setAdapter(this.mAdapter);
	}

	/**
	 * Attempts to restore accessibility focus to a given date. No-op if
	 * {@code day} is {@code null}.
	 *
	 * @param day
	 *            The date that should receive accessibility focus
	 * @return {@code true} if focus was restored
	 */
	private boolean restoreAccessibilityFocus(final CalendarDay day) {
		if (day == null) {
			return false;
		}

		final int childCount = getChildCount();
		for (int i = 0; i < childCount; i++) {
			final View child = getChildAt(i);
			if (child instanceof MonthView) {
				if (((MonthView) child).restoreAccessibilityFocus(day)) {
					return true;
				}
			}
		}

		return false;
	}

	public void setController(final DatePickerController controller) {
		this.mController = controller;
		this.mController.registerOnDateChangedListener(this);
		refreshAdapter();
		onDateChanged();
	}

	/**
	 * Sets the month displayed at the top of this view based on time. Override
	 * to add custom events when the title is changed.
	 */
	protected void setMonthDisplayed(final CalendarDay date) {
		this.mCurrentMonthDisplayed = date.month;
		invalidateViews();
	}

	/*
	 * Sets all the required fields for the list view. Override this method to
	 * set a different list view behavior.
	 */
	protected void setUpListView() {
		// Transparent background on scroll
		setCacheColorHint(0);
		// No dividers
		setDivider(null);
		// Items are clickable
		setItemsCanFocus(true);
		// The thumb gets in the way, so disable it
		setFastScrollEnabled(false);
		setVerticalScrollBarEnabled(false);
		setOnScrollListener(this);
		setFadingEdgeLength(0);
		// Make the scrolling behavior nicer
		setFriction(ViewConfiguration.getScrollFriction() * this.mFriction);
	}
}
